page.tsx 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053
  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { useParams, useRouter } from 'next/navigation';
  4. import { useAuth } from '@/lib/auth-context';
  5. import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation, TranscodeStatus } from '@/lib/api';
  6. import { useDropzone } from 'react-dropzone';
  7. import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
  8. async function safeCopy(text: string): Promise<void> {
  9. if (typeof window === 'undefined') return;
  10. if (navigator.clipboard?.writeText) {
  11. try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
  12. } else {
  13. // Fallback: create a temp input so we can use execCommand on insecure contexts
  14. const el = document.createElement('textarea');
  15. el.value = text;
  16. el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
  17. document.body.appendChild(el);
  18. el.focus(); el.select();
  19. try { document.execCommand('copy'); } catch { /* ignore */ }
  20. document.body.removeChild(el);
  21. }
  22. }
  23. const ROLE_COLORS: Record<string, string> = {
  24. ADMIN: 'badge-danger',
  25. EDITOR: 'badge-brand',
  26. REVIEWER:'badge-muted',
  27. VIEWER: 'badge-subtle',
  28. };
  29. const ROLE_LABELS: Record<string, string> = {
  30. ADMIN: 'Admin',
  31. EDITOR: 'Editor',
  32. REVIEWER:'Reviewer',
  33. VIEWER: 'Viewer',
  34. };
  35. export default function ProjectDetailPage() {
  36. const params = useParams();
  37. const projectId = params.projectId as string;
  38. const { user, token } = useAuth();
  39. const router = useRouter();
  40. const [project, setProject] = useState<Project | null>(null);
  41. const [members, setMembers] = useState<any[]>([]);
  42. const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
  43. const [assets, setAssets] = useState<Asset[]>([]);
  44. const [loading, setLoading] = useState(true);
  45. const [uploading, setUploading] = useState(false);
  46. const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
  47. // Invite form state (single shared form)
  48. const [inviteEmail, setInviteEmail] = useState('');
  49. const [inviteRole, setInviteRole] = useState('REVIEWER');
  50. const [inviting, setInviting] = useState(false);
  51. const [inviteError, setInviteError] = useState('');
  52. const [inviteSuccess, setInviteSuccess] = useState('');
  53. const [createdLink, setCreatedLink] = useState('');
  54. // Edit member role
  55. const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
  56. const [editingRole, setEditingRole] = useState('');
  57. const [updatingRole, setUpdatingRole] = useState(false);
  58. // Remove member
  59. const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null);
  60. const [removing, setRemoving] = useState(false);
  61. // Revoke invite
  62. const [revokingId, setRevokingId] = useState<string | null>(null);
  63. // Copy link
  64. const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
  65. const [inviteUrlMap, setInviteUrlMap] = useState<Record<string, string>>({});
  66. const canManage = members.some(m =>
  67. m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role)
  68. );
  69. const isAdmin = members.some(m =>
  70. m.user.id === user?.id && m.role === 'ADMIN'
  71. );
  72. const loadAll = useCallback(async () => {
  73. if (!token) return;
  74. try {
  75. const [{ project: p }, { assets: a }] = await Promise.all([
  76. projectsApi.get(token, projectId),
  77. assetsApi.list(token, projectId),
  78. ]);
  79. setProject(p);
  80. setMembers(p.members ?? []);
  81. setAssets(a);
  82. if (canManage) {
  83. const { invitations } = await invitationsApi.list(token, projectId);
  84. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  85. }
  86. } catch {
  87. router.push('/projects');
  88. } finally {
  89. setLoading(false);
  90. }
  91. }, [token, projectId, router, canManage]);
  92. useEffect(() => { loadAll(); }, [loadAll]);
  93. // ── Invite member ──────────────────────────────────────────────────────────
  94. const handleInvite = async (e: React.FormEvent) => {
  95. e.preventDefault();
  96. if (!token || !inviteEmail.trim()) return;
  97. if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.trim())) {
  98. setInviteError('Invalid email address');
  99. return;
  100. }
  101. setInviting(true);
  102. setInviteError('');
  103. setInviteSuccess('');
  104. setCreatedLink('');
  105. try {
  106. const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
  107. const { invitations } = await invitationsApi.list(token, projectId);
  108. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  109. setInviteUrlMap(prev => ({ ...prev, [inviteUrl.split('/').pop()!]: inviteUrl }));
  110. setInviteEmail('');
  111. setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
  112. setTimeout(() => setInviteSuccess(''), 3000);
  113. } catch (err) {
  114. setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
  115. } finally {
  116. setInviting(false);
  117. }
  118. };
  119. // ── Create & copy link ─────────────────────────────────────────────────────
  120. const handleCreateLink = async () => {
  121. if (!token || !inviteEmail.trim()) return;
  122. setInviting(true);
  123. setInviteError('');
  124. setInviteSuccess('');
  125. setCreatedLink('');
  126. try {
  127. const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
  128. const { invitations } = await invitationsApi.list(token, projectId);
  129. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  130. // API returns full URL now (e.g. http://localhost:3000/invite/xxx)
  131. await safeCopy(inviteUrl);
  132. setCreatedLink(inviteUrl);
  133. setInviteEmail('');
  134. } catch (err: any) {
  135. const msg = err instanceof Error ? err.message : String(err);
  136. if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) {
  137. setInviteError(`An invitation for "${inviteEmail.trim()}" is already pending or the user is already a member.`);
  138. } else {
  139. setInviteError(msg || 'Failed to create invitation link');
  140. }
  141. } finally {
  142. setInviting(false);
  143. }
  144. };
  145. // ── Change role ────────────────────────────────────────────────────────────
  146. const handleChangeRole = async (memberId: string) => {
  147. if (!token || !editingRole) return;
  148. setUpdatingRole(true);
  149. try {
  150. await projectsApi.updateMember(token, projectId, memberId, editingRole);
  151. setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: editingRole } : m));
  152. setEditingRoleId(null);
  153. } catch (err) {
  154. alert(err instanceof Error ? err.message : 'Failed to update role');
  155. } finally {
  156. setUpdatingRole(false);
  157. }
  158. };
  159. // ── Remove member ─────────────────────────────────────────────────────────
  160. const handleRemoveMember = async () => {
  161. if (!token || !confirmRemove) return;
  162. setRemoving(true);
  163. try {
  164. await projectsApi.removeMember(token, projectId, confirmRemove.id);
  165. setMembers(prev => prev.filter(m => m.id !== confirmRemove!.id));
  166. setConfirmRemove(null);
  167. } catch (err) {
  168. alert(err instanceof Error ? err.message : 'Failed to remove member');
  169. } finally {
  170. setRemoving(false);
  171. }
  172. };
  173. // ── Revoke invite ──────────────────────────────────────────────────────────
  174. const handleRevoke = async (invitationId: string) => {
  175. if (!token) return;
  176. setRevokingId(invitationId);
  177. try {
  178. await invitationsApi.revoke(token, invitationId);
  179. setPendingInvites(prev => prev.filter(i => i.id !== invitationId));
  180. } catch (err) {
  181. alert(err instanceof Error ? err.message : 'Failed to revoke invitation');
  182. } finally {
  183. setRevokingId(null);
  184. }
  185. };
  186. // ── Copy invite link ──────────────────────────────────────────────────────
  187. const handleCopyLink = async (invite: Invitation) => {
  188. const base = window.location.origin;
  189. const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`;
  190. await safeCopy(url);
  191. setCopiedInviteId(invite.id);
  192. setTimeout(() => setCopiedInviteId(null), 2000);
  193. };
  194. const handleDrop = async (acceptedFiles: File[]) => {
  195. if (!token || acceptedFiles.length === 0) return;
  196. setUploading(true);
  197. for (const file of acceptedFiles) {
  198. const formData = new FormData();
  199. formData.append('video', file);
  200. formData.append('projectId', projectId);
  201. formData.append('title', file.name.replace(/\.[^.]+$/, ''));
  202. try {
  203. const result = await assetsApi.upload(token, formData) as { asset: Asset };
  204. setAssets(prev => [result.asset, ...prev]);
  205. } catch (err) {
  206. console.error('Upload failed:', err);
  207. alert(`Upload failed: ${file.name}`);
  208. }
  209. }
  210. setUploading(false);
  211. };
  212. const { getRootProps, getInputProps, isDragActive } = useDropzone({
  213. onDrop: handleDrop,
  214. accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
  215. multiple: true,
  216. disabled: uploading,
  217. });
  218. const statusColors: Record<string, string> = {
  219. PENDING_REVIEW: 'status-pending',
  220. CHANGES_REQUESTED: 'status-changes',
  221. APPROVED: 'status-approved',
  222. REJECTED: 'status-rejected',
  223. };
  224. const statusLabels: Record<string, string> = {
  225. PENDING_REVIEW: 'Pending',
  226. CHANGES_REQUESTED: 'Changes',
  227. APPROVED: 'Approved',
  228. REJECTED: 'Rejected',
  229. };
  230. // ── Transcode status helpers ────────────────────────────────────────────────
  231. const transcodeColors: Record<TranscodeStatus, { text: string; dot: string; bg: string }> = {
  232. PENDING: { text: '#94A3B8', dot: 'bg-slate-400', bg: 'rgba(148,163,184,0.10)' },
  233. UPLOADING: { text: '#60A5FA', dot: 'bg-blue-400', bg: 'rgba(96,165,250,0.10)' },
  234. PROCESSING: { text: '#A78BFA', dot: 'bg-violet-400', bg: 'rgba(167,139,250,0.10)' },
  235. COMPLETED: { text: '#34D399', dot: 'bg-emerald-400', bg: 'rgba(52,211,153,0.10)' },
  236. FAILED: { text: '#F87171', dot: 'bg-red-400', bg: 'rgba(248,113,113,0.10)' },
  237. UNSUPPORTED_CODEC: { text: '#FBBF24', dot: 'bg-amber-400', bg: 'rgba(251,191,36,0.10)' },
  238. };
  239. const transcodeLabels: Record<TranscodeStatus, string> = {
  240. PENDING: 'Queued',
  241. UPLOADING: 'Uploading',
  242. PROCESSING: 'Processing',
  243. COMPLETED: 'Ready',
  244. FAILED: 'Failed',
  245. UNSUPPORTED_CODEC: 'Unsupported codec',
  246. };
  247. // Poll for assets that are still processing
  248. const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
  249. // ── Delete asset ─────────────────────────────────────────────────────────
  250. const [confirmDelete, setConfirmDelete] = useState<{ id: string; title: string } | null>(null);
  251. const [deletingId, setDeletingId] = useState<string | null>(null);
  252. const handleDeleteAsset = (id: string, title: string) => {
  253. setConfirmDelete({ id, title });
  254. };
  255. const confirmDeleteAsset = async () => {
  256. if (!token || !confirmDelete) return;
  257. setDeletingId(confirmDelete.id);
  258. try {
  259. await assetsApi.delete(token, confirmDelete.id);
  260. setAssets(prev => prev.filter(a => a.id !== confirmDelete.id));
  261. setConfirmDelete(null);
  262. } catch (err) {
  263. alert(err instanceof Error ? err.message : 'Failed to delete video');
  264. } finally {
  265. setDeletingId(null);
  266. }
  267. };
  268. useEffect(() => {
  269. const processingAssets = assets.filter(a =>
  270. ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
  271. );
  272. if (processingAssets.length === 0) {
  273. if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
  274. return;
  275. }
  276. if (pollingRef.current) return; // already polling
  277. pollingRef.current = setInterval(async () => {
  278. if (!token) return;
  279. try {
  280. const { assets: updated } = await assetsApi.list(token, projectId);
  281. setAssets(updated);
  282. } catch {}
  283. }, 3000);
  284. return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
  285. }, [token, projectId, assets]);
  286. if (loading) {
  287. return (
  288. <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  289. <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
  290. <div className="w-5 h-5 rounded-full animate-spin"
  291. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  292. <span className="text-sm">Loading…</span>
  293. </div>
  294. </div>
  295. );
  296. }
  297. return (
  298. <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
  299. {/* Header */}
  300. <header className="sticky top-0 z-10 px-8 py-4 flex items-center gap-5 shrink-0"
  301. style={{
  302. background: 'rgba(10,11,20,0.80)',
  303. backdropFilter: 'blur(12px)',
  304. borderBottom: '1px solid rgba(255,255,255,0.06)',
  305. }}>
  306. <button
  307. onClick={() => router.push('/projects')}
  308. className="flex items-center gap-1.5 text-sm transition-colors"
  309. style={{ color: 'var(--text-muted)' }}
  310. >
  311. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  312. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  313. </svg>
  314. Projects
  315. </button>
  316. <div className="w-px h-4" style={{ background: 'rgba(255,255,255,0.10)' }} />
  317. <div className="flex-1 min-w-0">
  318. <div className="flex items-center gap-2">
  319. <h1 className="text-sm font-semibold truncate" style={{ color: 'var(--text)' }}>
  320. {project?.name}
  321. </h1>
  322. {canManage && (
  323. <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
  324. style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
  325. {isAdmin ? 'Owner' : 'Editor'}
  326. </span>
  327. )}
  328. {!canManage && !isAdmin && (
  329. <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
  330. style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--text-subtle)' }}>
  331. {members.find(m => m.user.id === user?.id)?.role ?? 'Member'}
  332. </span>
  333. )}
  334. </div>
  335. {project?.description && (
  336. <p className="text-xs truncate mt-0.5" style={{ color: 'var(--text-muted)' }}>
  337. {project.description}
  338. </p>
  339. )}
  340. </div>
  341. {/* Tabs */}
  342. <div className="flex items-center gap-1 p-1 rounded-lg"
  343. style={{ background: 'rgba(255,255,255,0.04)' }}>
  344. {[['videos', 'Videos', assets.length], ['transcode', 'Transcode Tasks', assets.filter(a => a.transcodeStatus !== 'COMPLETED').length], ['members', 'Members', members.length]].map(([tab, label, count]) => (
  345. <button key={tab}
  346. onClick={() => setActiveTab(tab as any)}
  347. className="px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"
  348. style={{
  349. background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
  350. color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
  351. }}>
  352. {label}
  353. {tab !== 'members' && (count as number) > 0 && (
  354. <span className="text-[10px] px-1.5 py-0.5 rounded-full"
  355. style={{
  356. background: tab === 'transcode'
  357. ? 'rgba(167,139,250,0.25)'
  358. : 'rgba(255,255,255,0.06)',
  359. color: tab === 'transcode' ? '#A78BFA' : 'inherit',
  360. }}>
  361. {count}
  362. </span>
  363. )}
  364. {tab === 'members' && (
  365. <span className="ml-0.5 text-[10px] px-1.5 py-0.5 rounded-full"
  366. style={{ background: 'rgba(255,255,255,0.06)', color: 'inherit' }}>
  367. {members.length}
  368. </span>
  369. )}
  370. </button>
  371. ))}
  372. </div>
  373. <div className="text-xs px-2.5 py-1 rounded-full"
  374. style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
  375. {assets.length} video{assets.length !== 1 ? 's' : ''}
  376. </div>
  377. </header>
  378. <div className="px-8 py-6">
  379. {/* ── Videos Tab ───────────────────────────────────────────────────── */}
  380. {activeTab === 'videos' && (
  381. <>
  382. {/* Upload zone — only shown to EDITOR and ADMIN */}
  383. {canManage ? (
  384. <div
  385. {...getRootProps()}
  386. className="mb-8 rounded-2xl p-10 text-center cursor-pointer transition-all animate-fade-in"
  387. style={{
  388. background: isDragActive ? 'rgba(99,102,241,0.08)' : 'rgba(255,255,255,0.02)',
  389. border: isDragActive
  390. ? '1px solid rgba(99,102,241,0.40)'
  391. : '1px dashed rgba(255,255,255,0.10)',
  392. borderRadius: '16px',
  393. }}
  394. >
  395. <input {...getInputProps()} />
  396. {uploading ? (
  397. <div className="space-y-3">
  398. <div className="w-9 h-9 rounded-full mx-auto animate-spin"
  399. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  400. <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Uploading…</p>
  401. </div>
  402. ) : (
  403. <>
  404. <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center"
  405. style={{ background: 'rgba(99,102,241,0.10)', border: '1px solid rgba(99,102,241,0.15)' }}>
  406. <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  407. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  408. </svg>
  409. </div>
  410. <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
  411. {isDragActive ? 'Drop videos here' : 'Drag & drop videos, or click to browse'}
  412. </p>
  413. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  414. MP4, MOV, WebM — up to 500MB each
  415. </p>
  416. </>
  417. )}
  418. </div>
  419. ) : (
  420. <div className="mb-8 rounded-2xl p-6 text-center animate-fade-in"
  421. style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
  422. <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
  423. style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
  424. <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  425. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  426. </svg>
  427. </div>
  428. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  429. Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading videos.
  430. </p>
  431. </div>
  432. )}
  433. {/* Asset grid */}
  434. {assets.length === 0 ? (
  435. <div className="text-center py-20 rounded-2xl animate-fade-in"
  436. style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
  437. <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
  438. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
  439. <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
  440. <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
  441. </svg>
  442. </div>
  443. <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No videos yet</p>
  444. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>Upload your first video using the dropzone above</p>
  445. </div>
  446. ) : (
  447. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
  448. {assets.map((asset, i) => (
  449. <div key={asset.id}
  450. className="card overflow-hidden group"
  451. style={{ animation: `slideUp 0.25s ease-out ${i * 40}ms both` }}>
  452. {/* Thumbnail */}
  453. <div className="relative aspect-video" style={{ background: '#080810' }} onClick={() => router.push(`/review/${asset.id}`)}>
  454. {/* Play overlay — only show when ready */}
  455. {asset.transcodeStatus === 'COMPLETED' && (
  456. <>
  457. {asset.thumbnail ? (
  458. <img
  459. src={`/uploads/${asset.thumbnail}`}
  460. alt={asset.title}
  461. className="w-full h-full object-cover"
  462. style={{ opacity: 0.85 }}
  463. />
  464. ) : (
  465. <div className="w-full h-full flex items-center justify-center">
  466. <svg className="w-10 h-10" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
  467. <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
  468. <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  469. </svg>
  470. </div>
  471. )}
  472. <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
  473. style={{ background: 'rgba(0,0,0,0.35)' }}>
  474. <div className="w-12 h-12 rounded-full flex items-center justify-center"
  475. style={{ background: 'rgba(99,102,241,0.80)', boxShadow: '0 0 24px rgba(99,102,241,0.4)' }}>
  476. <svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
  477. <path d="M8 5v14l11-7z" />
  478. </svg>
  479. </div>
  480. </div>
  481. </>
  482. )}
  483. {/* Not ready — show transcode status overlay */}
  484. {asset.transcodeStatus !== 'COMPLETED' && (
  485. <div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
  486. {/* Animated spinner */}
  487. {['UPLOADING', 'PROCESSING', 'PENDING'].includes(asset.transcodeStatus) && (
  488. <div className="w-10 h-10 rounded-full animate-spin"
  489. style={{ borderColor: transcodeColors[asset.transcodeStatus]?.dot.replace('bg-','#').replace('-400','' ) || '#6366F1', borderTopColor: 'transparent', borderWidth: '3px' }} />
  490. )}
  491. {/* Error icon */}
  492. {asset.transcodeStatus === 'FAILED' && (
  493. <div className="w-10 h-10 rounded-full flex items-center justify-center"
  494. style={{ background: 'rgba(248,113,113,0.15)' }}>
  495. <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  496. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  497. </svg>
  498. </div>
  499. )}
  500. {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
  501. <div className="w-10 h-10 rounded-full flex items-center justify-center"
  502. style={{ background: 'rgba(251,191,36,0.15)' }}>
  503. <svg className="w-5 h-5" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  504. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  505. </svg>
  506. </div>
  507. )}
  508. {/* Status label */}
  509. <span className="text-xs font-medium px-2.5 py-1 rounded-full"
  510. style={{ background: transcodeColors[asset.transcodeStatus]?.bg, color: transcodeColors[asset.transcodeStatus]?.text }}>
  511. {transcodeLabels[asset.transcodeStatus]}
  512. </span>
  513. </div>
  514. )}
  515. {/* Progress bar */}
  516. {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && (
  517. <div className="absolute bottom-0 left-0 right-0 h-1 overflow-hidden"
  518. style={{ background: 'rgba(0,0,0,0.3)' }}>
  519. <div
  520. className="h-full transition-all duration-500"
  521. style={{
  522. width: `${asset.transcodeProgress}%`,
  523. background: 'linear-gradient(90deg, #818CF8, #A78BFA)',
  524. }}
  525. />
  526. </div>
  527. )}
  528. {/* Duration badge */}
  529. {asset.duration && asset.transcodeStatus === 'COMPLETED' && (
  530. <span className="absolute bottom-2 right-2 text-xs px-1.5 py-0.5 rounded-md font-mono"
  531. style={{ background: 'rgba(0,0,0,0.70)', color: '#E2E8F0' }}>
  532. {(() => { const m = Math.floor(asset.duration! / 60); const s = Math.floor(asset.duration! % 60); return `${m}:${s.toString().padStart(2,'0')}`; })()}
  533. </span>
  534. )}
  535. {/* Codec badge */}
  536. {asset.codec && asset.transcodeStatus !== 'COMPLETED' && (
  537. <span className="absolute top-2 left-2 text-[10px] px-1.5 py-0.5 rounded font-mono"
  538. style={{ background: 'rgba(0,0,0,0.6)', color: '#94A3B8' }}>
  539. {asset.codec}
  540. </span>
  541. )}
  542. </div>
  543. {/* Info */}
  544. <div className="p-4">
  545. <div className="flex items-start justify-between gap-2 mb-2">
  546. <h3 className="text-sm font-medium truncate flex-1 transition-colors"
  547. style={{ color: 'var(--text)' }}>
  548. {asset.title}
  549. </h3>
  550. <span className={`badge shrink-0 ${statusColors[asset.status]}`}>
  551. {statusLabels[asset.status]}
  552. </span>
  553. </div>
  554. {/* Transcode status row */}
  555. {asset.transcodeStatus !== 'COMPLETED' && (
  556. <div className="mb-2 flex items-center gap-1.5">
  557. <div
  558. className={`w-1.5 h-1.5 rounded-full shrink-0 ${['UPLOADING','PROCESSING'].includes(asset.transcodeStatus) ? 'animate-pulse' : ''} ${transcodeColors[asset.transcodeStatus]?.dot}`}
  559. />
  560. <span className="text-[11px] truncate" style={{ color: transcodeColors[asset.transcodeStatus]?.text }}>
  561. {transcodeLabels[asset.transcodeStatus]}
  562. {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && asset.transcodeProgress > 0
  563. ? ` — ${asset.transcodeProgress}%`
  564. : ''}
  565. </span>
  566. {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
  567. <span className="text-[10px] truncate" style={{ color: '#F87171' }}>
  568. : {asset.transcodeError}
  569. </span>
  570. )}
  571. {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
  572. <span className="text-[10px] truncate" style={{ color: '#FB923C' }}>
  573. — will re-encode to H.264
  574. </span>
  575. )}
  576. </div>
  577. )}
  578. <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
  579. <span>{(asset as any)._count?.comments ?? 0} comment{((asset as any)._count?.comments ?? 0) !== 1 ? 's' : ''}</span>
  580. <span className="w-1 h-1 rounded-full" style={{ background: 'rgba(255,255,255,0.12)' }} />
  581. <span>{new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
  582. <div className="flex-1" />
  583. {canManage && (
  584. <button
  585. onClick={(e) => { e.stopPropagation(); handleDeleteAsset(asset.id, asset.title); }}
  586. className="p-1 rounded transition-colors hover:bg-red-500/20 flex-shrink-0"
  587. title="Delete video"
  588. >
  589. <svg className="w-3.5 h-3.5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  590. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  591. </svg>
  592. </button>
  593. )}
  594. </div>
  595. </div>
  596. </div>
  597. ))}
  598. </div>
  599. )}
  600. </>
  601. )}
  602. {/* ── Transcode Tasks Tab ─────────────────────────────────────────── */}
  603. {activeTab === 'transcode' && (
  604. <div className="animate-fade-in">
  605. <TranscodeTasksPanel
  606. assets={assets}
  607. token={token}
  608. canManage={canManage}
  609. onDelete={handleDeleteAsset}
  610. onCancel={async (id) => {
  611. if (!token) return;
  612. try {
  613. await assetsApi.cancelTranscode(token, id);
  614. setAssets(prev => prev.map(a => a.id === id ? {
  615. ...a,
  616. transcodeStatus: 'PENDING',
  617. transcodeProgress: 0,
  618. transcodeError: null,
  619. hlsPath: null,
  620. } : a));
  621. } catch (err) {
  622. alert(err instanceof Error ? err.message : 'Failed to cancel transcode');
  623. }
  624. }}
  625. />
  626. </div>
  627. )}
  628. {/* ── Members Tab ─────────────────────────────────────────────────── */}
  629. {activeTab === 'members' && (
  630. <div className="max-w-3xl animate-fade-in">
  631. {/* Invite form — single form, shared email + role */}
  632. {canManage && (
  633. <div className="card p-5 mb-6">
  634. <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
  635. Invite someone
  636. </h2>
  637. <div className="space-y-3">
  638. <form
  639. onSubmit={e => { e.preventDefault(); handleInvite(e); }}
  640. className="flex items-end gap-3 flex-wrap"
  641. >
  642. <div className="flex-1 min-w-[180px]">
  643. <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
  644. Email address
  645. </label>
  646. <input
  647. type="email"
  648. className="input"
  649. value={inviteEmail}
  650. onChange={e => setInviteEmail(e.target.value)}
  651. placeholder="colleague@company.com"
  652. />
  653. </div>
  654. <div className="w-36">
  655. <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
  656. <select
  657. className="input"
  658. value={inviteRole}
  659. onChange={e => setInviteRole(e.target.value)}
  660. >
  661. {Object.entries(ROLE_LABELS).map(([value, label]) => (
  662. <option key={value} value={value}>{label}</option>
  663. ))}
  664. </select>
  665. </div>
  666. {/* Both buttons share the same email + role from this single form */}
  667. <button
  668. type="button"
  669. disabled={inviting || !inviteEmail.trim()}
  670. onClick={handleCreateLink}
  671. className="btn btn-secondary btn-md"
  672. title="Create invite link and copy to clipboard"
  673. >
  674. {inviting ? 'Creating…' : (
  675. <span className="flex items-center gap-1.5">
  676. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  677. <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
  678. </svg>
  679. Copy Link
  680. </span>
  681. )}
  682. </button>
  683. <button
  684. type="submit"
  685. disabled={inviting || !inviteEmail.trim()}
  686. className="btn btn-primary btn-md"
  687. title="Send invite — link is included automatically"
  688. >
  689. {inviting ? 'Sending…' : 'Send Invite'}
  690. </button>
  691. </form>
  692. {/* Created link feedback */}
  693. {createdLink && (
  694. <div className="rounded-lg p-3 animate-scale-in"
  695. style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
  696. <div className="flex items-center gap-2 mb-1.5">
  697. <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  698. <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  699. </svg>
  700. <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied!</span>
  701. </div>
  702. <p className="text-[10px] break-all" style={{ color: 'rgba(134,239,172,0.7)' }}>
  703. {createdLink}
  704. </p>
  705. </div>
  706. )}
  707. {inviteError && (
  708. <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
  709. )}
  710. {inviteSuccess && (
  711. <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>
  712. )}
  713. </div>
  714. </div>
  715. )}
  716. {/* Members list */}
  717. <div className="card overflow-hidden mb-6">
  718. <div className="px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
  719. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
  720. Members ({members.length})
  721. </h2>
  722. </div>
  723. {members.length === 0 ? (
  724. <div className="p-8 text-center">
  725. <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No members yet</p>
  726. </div>
  727. ) : (
  728. <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  729. {members.map(m => {
  730. const isMe = m.user.id === user?.id;
  731. const canEdit = isAdmin && !isMe;
  732. return (
  733. <div key={m.id}
  734. className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
  735. {/* Avatar */}
  736. <div className="w-9 h-9 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
  737. style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
  738. {m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
  739. </div>
  740. {/* Info */}
  741. <div className="flex-1 min-w-0">
  742. <div className="flex items-center gap-2">
  743. <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
  744. {m.user.name}
  745. {isMe && <span className="ml-1.5 text-[10px]" style={{ color: 'var(--text-subtle)' }}>(you)</span>}
  746. </span>
  747. </div>
  748. <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
  749. </div>
  750. {/* Joined date */}
  751. <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
  752. {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
  753. </span>
  754. {/* Role */}
  755. {editingRoleId === m.id ? (
  756. <div className="flex items-center gap-2 shrink-0">
  757. <select
  758. className="input text-xs py-1.5"
  759. value={editingRole}
  760. onChange={e => setEditingRole(e.target.value)}
  761. autoFocus
  762. >
  763. {Object.entries(ROLE_LABELS).map(([v, l]) => (
  764. <option key={v} value={v}>{l}</option>
  765. ))}
  766. </select>
  767. <button
  768. onClick={() => handleChangeRole(m.id)}
  769. disabled={updatingRole}
  770. className="btn btn-primary btn-sm px-2"
  771. title="Save"
  772. >
  773. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  774. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  775. </svg>
  776. </button>
  777. <button
  778. onClick={() => setEditingRoleId(null)}
  779. className="btn btn-secondary btn-sm px-2"
  780. title="Cancel"
  781. >
  782. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  783. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  784. </svg>
  785. </button>
  786. </div>
  787. ) : (
  788. <div className="flex items-center gap-2 shrink-0">
  789. <span className={`badge ${ROLE_COLORS[m.role] ?? 'badge-muted'}`}>
  790. {ROLE_LABELS[m.role] ?? m.role}
  791. </span>
  792. {canEdit && (
  793. <button
  794. onClick={() => { setEditingRoleId(m.id); setEditingRole(m.role); }}
  795. className="btn btn-secondary btn-sm"
  796. title="Change role"
  797. >
  798. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  799. <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
  800. </svg>
  801. </button>
  802. )}
  803. {isAdmin && !isMe && (
  804. <button
  805. onClick={() => setConfirmRemove({ id: m.user.id, name: m.user.name })}
  806. className="btn btn-danger btn-sm"
  807. title="Remove from project"
  808. >
  809. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  810. <path strokeLinecap="round" strokeLinejoin="round" d="M22 12h-4l-3 9L9 3l-3 9H2" />
  811. </svg>
  812. </button>
  813. )}
  814. </div>
  815. )}
  816. </div>
  817. );
  818. })}
  819. </div>
  820. )}
  821. </div>
  822. {/* Pending invitations */}
  823. {canManage && (
  824. <div className="card overflow-hidden">
  825. <div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
  826. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
  827. Pending invitations
  828. </h2>
  829. <span className="text-xs px-2 py-0.5 rounded-full"
  830. style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
  831. {pendingInvites.length}
  832. </span>
  833. </div>
  834. {pendingInvites.length === 0 ? (
  835. <div className="p-8 text-center">
  836. <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>No pending invitations</p>
  837. </div>
  838. ) : (
  839. <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  840. {pendingInvites.map(inv => (
  841. <div key={inv.id}
  842. className="flex items-center gap-4 px-5 py-4">
  843. {/* Icon */}
  844. <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
  845. style={{ background: 'rgba(99,102,241,0.08)' }}>
  846. <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  847. <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
  848. </svg>
  849. </div>
  850. {/* Info */}
  851. <div className="flex-1 min-w-0">
  852. <div className="flex items-center gap-2">
  853. <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
  854. <span className={`badge ${ROLE_COLORS[inv.role] ?? 'badge-muted'}`}>
  855. {ROLE_LABELS[inv.role] ?? inv.role}
  856. </span>
  857. </div>
  858. <div className="flex items-center gap-3 mt-0.5 text-xs" style={{ color: 'var(--text-subtle)' }}>
  859. <span>Sent {new Date(inv.createdAt).toLocaleDateString()}</span>
  860. <span>·</span>
  861. <span>Expires {new Date(inv.expiresAt).toLocaleDateString()}</span>
  862. </div>
  863. </div>
  864. {/* Actions */}
  865. <div className="flex items-center gap-1.5 shrink-0">
  866. <button
  867. onClick={() => handleCopyLink(inv)}
  868. className="btn btn-secondary btn-sm"
  869. title="Copy invite link"
  870. >
  871. {copiedInviteId === inv.id ? (
  872. <svg className="w-3.5 h-3.5" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  873. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  874. </svg>
  875. ) : (
  876. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  877. <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
  878. </svg>
  879. )}
  880. </button>
  881. <button
  882. onClick={() => handleRevoke(inv.id)}
  883. disabled={revokingId === inv.id}
  884. className="btn btn-danger btn-sm"
  885. title="Revoke invitation"
  886. >
  887. {revokingId === inv.id ? '…' : (
  888. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  889. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  890. </svg>
  891. )}
  892. </button>
  893. </div>
  894. </div>
  895. ))}
  896. </div>
  897. )}
  898. {pendingInvites.length > 0 && (
  899. <div className="px-5 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  900. <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
  901. Invitation links expire after 7 days. Copy the link and send it manually, or ask the recipient to check their email.
  902. </p>
  903. </div>
  904. )}
  905. </div>
  906. )}
  907. </div>
  908. )}
  909. </div>
  910. {/* Delete asset confirm modal */}
  911. {confirmDelete && (
  912. <div className="fixed inset-0 z-50 flex items-center justify-center"
  913. style={{ background: 'rgba(0,0,0,0.7)' }}>
  914. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  915. <div className="flex items-center gap-3 mb-4">
  916. <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
  917. style={{ background: 'rgba(248,113,113,0.15)' }}>
  918. <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  919. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  920. </svg>
  921. </div>
  922. <div>
  923. <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>Delete video?</h3>
  924. <p className="text-xs mt-0.5 truncate max-w-[220px]" style={{ color: 'var(--text-muted)' }}>
  925. "{confirmDelete.title}"
  926. </p>
  927. </div>
  928. </div>
  929. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  930. This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
  931. </p>
  932. <div className="flex gap-3 justify-end">
  933. <button
  934. onClick={() => setConfirmDelete(null)}
  935. disabled={!!deletingId}
  936. className="btn btn-secondary btn-md"
  937. >
  938. Cancel
  939. </button>
  940. <button
  941. onClick={confirmDeleteAsset}
  942. disabled={!!deletingId}
  943. className="btn btn-danger btn-md"
  944. >
  945. {deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
  946. </button>
  947. </div>
  948. </div>
  949. </div>
  950. )}
  951. {/* Remove member confirm modal */}
  952. {confirmRemove && (
  953. <div className="fixed inset-0 z-50 flex items-center justify-center"
  954. style={{ background: 'rgba(0,0,0,0.7)' }}>
  955. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  956. <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
  957. Remove {confirmRemove.name}?
  958. </h3>
  959. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  960. They'll lose access to this project and all its videos. They can rejoin if invited again.
  961. </p>
  962. <div className="flex gap-3 justify-end">
  963. <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">
  964. Cancel
  965. </button>
  966. <button
  967. onClick={handleRemoveMember}
  968. disabled={removing}
  969. className="btn btn-danger btn-md"
  970. >
  971. {removing ? 'Removing…' : 'Remove'}
  972. </button>
  973. </div>
  974. </div>
  975. </div>
  976. )}
  977. </div>
  978. );
  979. }